Skip to content

fix(objectql): ScopedContext.transaction 补齐 ADR-0067 D2 的 ambient join (#6168) - #6403

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-6168-scoped-transaction-join
Aug 7, 2026
Merged

fix(objectql): ScopedContext.transaction 补齐 ADR-0067 D2 的 ambient join (#6168)#6403
baozhoutao merged 3 commits into
mainfrom
claude/issue-6168-scoped-transaction-join

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6168

问题

ObjectQL.transaction() 的第一件事是 ADR-0067 D2 的 join 判定:已有 ambient 事务就在其中跑回调并报 owned: false,不自开嵌套的 driver 事务。

ScopedContext.transaction() —— 同一原语的第二份实现,hook / action 体里的 ctx.api.transaction(fn) —— 没有这一支,直接取默认驱动 beginTransaction()。它自己的 TSDoc 称自己是「同一件事的第二份实现」,并逐条对齐了 ADR-0119 D1 的两条 caveat,唯独 join 这条从未对齐;方法体内甚至留着一行注释明说「本面永远 OPEN(没有自己的 D2 join 支)」。

后果两条,都是 D2 存在的理由:

  1. 再要一条连接 —— 单连接池(D2 点名的 knex/SQLite)上就是死锁;
  2. 内层自行 commit,写入存活过外层回滚 —— 调用者被告知工作单元已撤销,而其中若干行仍在,无报错、无日志

#5696owned 信号在这一面永远报 true。信号没说谎(它确实每次都自开),说谎的是被它如实描述的行为。

修法

ScopedContext.transaction 顶部补上与引擎面同形、同位的 join 支:

TSDoc 同步更新,并写明声明式边界:join 只读引擎 ambient txStore,而离散 beginTransaction/commit/rollback 三件套刻意不写该 store(句柄跨 setImmediate 显式穿线,ALS 不存活),因此三件套句柄在此不可见、不会被误当成 ambient —— 这正是 issue 要求的那条约束。交叉引用 #6167

一处与 issue 正文的出入(如实记录)

issue 把可达路径写作「沙箱 hook 体里的 ctx.api.transaction」。实读 packages/runtime/src/sandbox/quickjs-runner.ts:526-600:QuickJS 面的 ctx.api.transaction(fn) 是 VM 侧糖,底下驱动 __txBegin/__txCommit/__txRollback 三个 host leaf,走的是三件套,不是本方法。所以 VM 内的 body 不在本次 join 的覆盖范围内。

本方法真正的可达面是进程内 body:HookContext.api 就是 ScopedContext(ObjectQL.buildHookApi),action body 的 ctx.api 同样是它(packages/runtime/src/action-execution.ts:868buildActionApi),外加任何直接 engine.createContext(...).transaction(...) 的插件/应用代码。缺陷本身与 issue 所述完全一致,只是可达面比正文写的窄一档。测试用的是真 hook(registerHook 注册 afterInsert,在 engine.transaction() 内被写触发),不是 createContext 替身。

测试(9 例新增)

engine-ambient-transaction.test.ts(+6):

  • join 外层:同一句柄、owned: false只有一次 begin;
  • 回滚持久性钉子:内层写 + 外层回滚 → 内层写消失;
  • 外层成功时内外一起 commit;
  • 单连接池钉子:嵌套调用不再申请第二条连接;
  • 无 ambient 时仍自开(owned: true)—— 回归;
  • 事务关闭后不误 join —— store 不泄漏。

回滚钉子需要一个 rollback-honest 的 driver 双替:带句柄的写按句柄暂存,commit 落库、rollback 丢弃。文件里原有的双替只记录 rollback 被调用过,那只能钉「回滚被调用」,钉不到本 issue 真正关心的事实 —— 事后那行还在不在。

engine-transaction-contract.test.ts(+2):契约对齐(join 的句柄与信号;require: true 下由 join 满足而非拒绝)。原有用例标题里「本面永远自开」的说法已失效,改为 reports owned: true when it opens one

engine-transaction-same-origin.test.ts(+2):join 后 #5351 判定走外层属主(跨源业务写被拒,transactionDatasourceprimary;系统台账照旧豁免、无外来句柄、一条 debug)。

反向验证(先立预测,后跑)

预测:移除 join 支 → ambient 文件的 join 三例 + 契约两例翻红,且回滚例的红呈「残留」形状;无 ambient 与已关闭两例保持绿;同源两例保持绿(它们量的是交互,不是 join 本身)。

实测逐条吻合:

× joins the outer transaction — same handle, owned: false, no second begin
  → expected true to be false
× the joined write is UNDONE by the outer rollback — the durability pin
  → expected [ 'inner' ] to deeply equal []
× the joined write commits with the outer one when the outer succeeds
  → expected [ { __trx: 1 }, { __trx: 2 } ] to have a length of 1 but got 2
✓ with NO ambient transaction it still OPENS one — owned: true, unchanged
✓ does not join a transaction that has already closed
✓ (same-origin) refuses ... against the OUTER owner
✓ (same-origin) carves an audit row out of a JOINED ScopedContext.transaction

单连接池例单独反验,报 pool exhausted: no connection available (max=1),栈正是 issue 所述机制:

at ScopedContext.transaction (engine.ts:7590)
at registerHook handler
at ObjectQL.triggerHooks (engine.ts:1406)
at ObjectQL.insert (engine.ts:5271)

范围外发现(PD #10,已另开单,未在本 PR 修改)

门禁

pnpm --filter @objectstack/objectql test 140 文件 / 2318 通过;typecheck 干净;check:engine-double-contract OK(80 pinned / 133 DEBT / 4 exempt,未变动);check:durability-log-level OK;check-nul-bytes OK。已 merge origin/main(761a0ba)后重跑。

claude added 3 commits August 7, 2026 16:37
#6168)

`ObjectQL.transaction()` 的第一件事一直是 D2 的 join 判定:已有 ambient 事务
就在其中运行回调并报 `owned: false`,不再自开嵌套的 driver 事务。
`ScopedContext.transaction()` —— 同一原语的第二份实现,hook / action 体里的
`ctx.api.transaction(fn)` —— 没有这一支,直接取默认驱动 `beginTransaction()`。

它自己的 TSDoc 称自己是「同一件事的第二份实现」并逐条对齐了 ADR-0119 D1 的
caveat,唯独 join 这条一直没对齐。本次补上:同一分支、同一位置(在驱动查找和
`opts.require` 之前 —— ambient 事务本身就是事务,声明「不能没有事务」的调用者
由 join 满足)。

行为变化:内层从此随外层回滚。此前一个由 `engine.transaction()` 触发的 hook,
其体内的 `ctx.api.transaction(fn)` 会另开一个事务并自行 commit,写入因此
**存活过外层回滚** —— 调用者被告知工作单元已撤销,而其中若干行仍在,无报错也
无日志;同时它占住第二条连接,正是 D2 要避开的单连接池死锁。

join 只读引擎的 ambient store,因此离散 begin/commit/rollback 三件套(刻意不写
该 store)的显式句柄不可见、不会被误当成 ambient —— 该边界连同 #6167 的交叉
引用一并写入 TSDoc。

测试:`engine-ambient-transaction.test.ts` 新增 5 例(含回滚持久性钉子:内层写
在外层回滚后必须消失,由一个 rollback-honest 的 driver 双替按句柄暂存/丢弃来
测),`engine-transaction-contract.test.ts` 新增 2 例契约对齐,
`engine-transaction-same-origin.test.ts` 新增 2 例证实 #5351 同源判定在 join 后
走外层属主、拒绝与豁免形状不变。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
ADR-0067 D2 理由的另一半:第二次 `beginTransaction` 会向连接池要第二条连接,
而单连接池(D2 点名的 knex/SQLite 那个)给不出来。真实池在此**阻塞**——那就是
死锁——但如实建模阻塞的测试只能靠 vitest 超时判红:慢,且并行下不稳。

故双替建模「size=1 且带 acquire 超时」的池:第二次签出直接拒绝而不是排队。
拒绝是对挂起的替身;两种建模下被如实测量的都是同一个致因——到底有没有去要
第二条连接。

反向验证:移除 join 支后本例翻红,报 `pool exhausted: no connection available
(max=1)`,栈为 registerHook handler → ScopedContext.transaction →
beginTransaction,正是 issue 所述机制。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 4:58pm

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 17:06
@baozhoutao
baozhoutao enabled auto-merge August 7, 2026 17:07
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit a841151 Aug 7, 2026
26 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-6168-scoped-transaction-join branch August 7, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants